Check the deletion tombstone in rules, not just in the profile callables - #183
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
A deleted account could keep writing until its id token expired.
The cascade deletes users/{uid} and admin/state, then deletes the Auth record
last — deliberately, so a partial failure is still retryable ("Auth is the
last bridge we burn"). But rules read the resulting absence as good news:
isNotDeleting() `!exists(userDoc)` → not deleting
isNotBanned() `!exists(adminDoc)` → not banned
and rules validate only a JWT's signature and expiry, never that the Auth user
still exists. So for the rest of that token's life the holder — the ex-owner,
or anyone who took the token — could re-create the world-readable user doc
through completeOnboarding (a setDoc merge, which is a CREATE once the doc is
gone, and onboardingComplete is allowlisted), then like and bookmark other
people's posts. Each like fired onLikeCreated and incremented likeCount for a
uid that no longer exists, with no cleanup pass left to revisit it.
users/{uid} create had neither isNotBanned() nor isNotDeleting() on it at all.
The mechanism to fix it already existed and was only half-wired.
userDeletionTombstones/{uid} is written BEFORE the user doc is removed, with a
24h TTL that comfortably outlives the 1h token it exists to outlive, and
ensureUserProfileCallable and updateUserProfileCallable already check it. The
paths governed by rules did not.
isNotDeleting() now checks both signals: deletionPending covers the cascade
while it runs, the tombstone covers after it finishes. Every gate already
built on isNotDeleting() — likes, bookmarks, settings, blockedUsers — is fixed
by that one change, and isNotDeleting() is added to users/{uid} create and to
the owner branch of update. Rules-internal exists() is not governed by the
tombstone collection's own `allow read: if false`.
Deletes stay open, same as for banned and mid-deletion accounts: a client
tearing itself down must still be able to remove rows the cascade missed.
Four new tests fail on the old rules and pass on these. Three more pass on
both, on purpose: two pin that a brand-new signup — no tombstone and no user
doc, which is the same "absent" the bug relied on — can still create its
profile and like a post, and one pins that a finished account can still delete
its own leftovers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
renrenmimi
force-pushed
the
fix/deleted-account-tombstone-in-rules
branch
from
September 3, 2026 20:51
cf69662 to
3b829ff
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A deleted account could keep writing until its id token expired.
Why the absence read as "fine"
functions/src/users.ts:591-607 deletes
users/{uid}and then deletes the Auth record last — deliberately, and the comment says why: "Auth is the last bridge we burn", so a partial cleanup failure is still retryable. Reasonable. But the rules read the resulting absence as good news:isNotDeleting():66!exists(userDoc)isNotBanned():53!exists(adminDoc)And rules validate only a JWT's signature and expiry — never that the Auth user still exists. So for the rest of that token's life (≤1h) the holder could:
completeOnboarding(users.ts:234-237) is asetDoc(..., {merge:true}), which with the doc deleted is a create — and firestore.rules:134 had neitherisNotBanned()norisNotDeleting()on it, whileonboardingCompleteis allowlisted.onLikeCreatedand incrementslikeCountfor a uid that no longer exists — and the cleanup pass that would have removed it has already finished.Actor is the ex-owner, or anyone holding a token taken before deletion.
This is exactly the brake the comment at firestore.rules:59-62 claims to provide. It only ever covered the cascade while it ran.
The fix was half-built already
userDeletionTombstones/{uid}is written before the user doc is removed (users.ts:506-519) with a 24h TTL — chosen to outlive the 1h token, per its own comment.ensureUserProfileCallableandupdateUserProfileCallablealready check it (users.ts:291, :407).Only the rules-governed paths didn't. So
isNotDeleting()now checks both signals:deletionPending— the cascade while it runsThat one change fixes every gate already built on
isNotDeleting(): likes, bookmarks, settings, blockedUsers. PlusisNotDeleting()added tousers/{uid}create and to the owner branch of update.Rules-internal
exists()is not governed by the tombstone collection's ownallow read: if false, so it stays unreadable to clients.Deletes stay open, same as for banned and mid-deletion accounts — a client tearing itself down must still be able to remove rows the cascade missed. There's a test for that.
Cost
One extra
exists()whereverisNotDeleting()is reached. Worst case is a like create:isNotBanned(≤2) +isNotDeleting(≤3) +exists(posts/…)(1) = 6 document accesses, against Firestore's limit of 10 per single-document request.Tests
tests/rules/ban-and-engagement.test.ts, 18 → 25 tests.Four fail on the old rules and pass on these:
The fixture is what
deleteUserAccountactually leaves behind: no user doc, noadmin/state, tombstone present.Three pass on both, on purpose. Two of them matter a lot: a brand-new signup has no tombstone and no user doc — the same "absent" the bug relied on — so they pin that a fresh uid can still create its profile and like a post. That's the guard against fixing this by breaking onboarding. The third pins that a finished account can still delete its own leftovers.
Full local run: rules 52/52, functions
test:emulator73/73, root lint /typecheck:tests/ build clean.Deploy
Rules only.
Note on stacking
This branches from
main, not from #182, and both touchfirestore.rulesin different places (isAdmin/admin/statethere,isNotDeleting/userscreate-update here). Whichever merges second will likely need a trivial rebase — worth taking them one at a time rather than together.🤖 Generated with Claude Code